Skip to content

feat(cli): report crashes in the CLI's own code to Checkly [ask] - #1590

Open
aluedeke wants to merge 2 commits into
mainfrom
andreas/cli-error-reporting
Open

aluedeke wants to merge 2 commits into
mainfrom
andreas/cli-error-reporting

Conversation

@aluedeke

@aluedeke aluedeke commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Affected Components

  • CLI
  • Create CLI
  • Test
  • Docs
  • Examples
  • Other (release workflow)

Notes for the Reviewer

The CLI had a // TODO: we can add Sentry here in BaseCommand.catch. This PR reports crashes caused by bugs in the CLI's own code to Checkly's own error tracking (the RUM ingest at rum.checklyhq.com, which speaks the Sentry envelope protocol). Nothing goes to sentry.io.

What is reported. Only a TypeError, ReferenceError or RangeError whose innermost stack frame is in the CLI's own files (not its node_modules), at most one per run. Errors in user code, dependencies, failed checks, API and network errors, and Node's ERR_INVALID_URL for a bad user URL are not. Wrapped errors (new Error('…', { cause }), e.g. while loading a check file) are found through the cause chain, and only the inner error is sent. A user's invalid configuration that makes a construct crash is reported on purpose: bad config should produce a clear message, not a crash.

Where it hooks in. There is no single place that sees every crash: deploy, test, trigger and many other branches print the error and exit without rethrowing. So errors are noted where they are thrown (BaseCommand.catch), printed (CommandStyle.longError, plus the 7 sites that print only err.message) or emitted by AbstractCheckRunner, and sent from BaseCommand.finally, which oclif runs on every path before its handler prints and calls process.exit.

What a report contains. Error type, scrubbed message and stack; CLI version, command id, CLI mode; OS, arch, runtime version. Scrubbing: frames outside the CLI become <project file>; packages the CLI does not depend on directly become <dependency>; paths under cwd/home become <project file>/<home file> (segment boundaries, forward slashes, file:// URLs, case-insensitive on Windows); cu_ keys, Bearer/Basic tokens, URL userinfo, *token=/*secret=/*api_key=-style values and the values of CHECKLY_API_KEY/CHECKLY_ACCOUNT_ID. No server name, user, argv or environment.

Release only. The DSN is not in the repo. The release workflow writes it from the repository variable CHECKLY_CLI_ERROR_DSN to packages/cli/error-reporting.json (gitignored, in files) for prerelease and release builds. Canary and local builds (0.0.0*, 0.0.1-dev) never report. Until the variable is set, nothing is reported, so this is safe to merge first. The key is write-only and can be revoked in the ingest, as with every CLI that ships a crash-report key (Wrangler, Shopify, flyctl).

Opt-out. CHECKLY_NO_ERROR_REPORTING=1 or DO_NOT_TRACK=1. Documented in the README ("Error reporting") and CLAUDE.md.

Cost to the user. @sentry/core is loaded with dynamic import() only when a report is sent. The report waits at most 3 s (timeout plus AbortSignal.timeout) and never throws, so exit codes are unchanged. For an error that ends the command through catch, the report is sent before oclif prints the error, because oclif exits right after printing; this delay only happens on a real CLI bug.

Not covered (follow-ups). Crashes in un-awaited callbacks and unhandled rejections (needs a process.on('unhandledRejection') handler, which changes how the CLI exits).

Code layout. src/services/error-reporting/: classify.ts (is it a CLI bug; reads stacks with Sentry's Node parser, the one the event uses, and traces eval frames to the file that ran them), scrub.ts (removes what names the user), event.ts (builds the event), report.ts (run state, DSN, sending).

Testing. 24 unit tests in error-reporting/__tests__/error-reporting.spec.ts using a real local HTTP server and real .mjs files in an install directory with a space in its name (file-URL stacks with %20, as on Windows). Each new test fails with its bug restored. Full suite: 239 files, 4586 passed / 7 skipped; lint, tsc, test:types clean. Manually: the packed tarball installed under my install with a bug injected in dist/rest/checks.js → checks list exits 1, prints the error, sends one envelope; with DO_NOT_TRACK=1 none; a thrown bug in whoami also sends one. The envelope was parsed by the ingest's own Go sentry.ParseEnvelope, and axios's User-Agent reaches rum.checklyhq.com through Cloudflare (401 invalid sentry_key with a fake key). Windows is only covered by CI.

Open question for Simo: keep @sentry/core or replace it? It adds 14.2 MB to every install (@sentry/core 5.4 MB + @sentry/conventions 8.8 MB). Today checkly@10.1.0 installs 55 MB in 211 packages, so +26 %. We use only uuid4, a stack-line parser, DSN parsing and envelope serialization, which is about 40 lines with crypto.randomUUID.

New Dependency Submission

@sentry/core@^11.10.0 (supports Node >=22.12, published 2026-10-28; one dependency, @sentry/conventions; no OpenTelemetry). It builds and serializes Sentry envelopes and parses Node stack lines, so the events match what the ingest's Sentry parser expects. @sentry/node was not used: it would auto-instrument the process and pull in OpenTelemetry. Whether to keep it at all is the open question above.

When a TypeError, ReferenceError or RangeError raised in the CLI's own
files ends or fails a command, send one Sentry envelope to Checkly's
error ingest. Errors in user code, dependencies, failed checks, API and
network errors are not reported.

Errors are noted where they are thrown (BaseCommand.catch), printed
(CommandStyle.longError and the sites that print only err.message) or
emitted by the check runner, and sent from BaseCommand.finally, so
commands that print their errors and exit are covered too. Wrapped
errors are found through their cause chain.

The report carries no paths outside the CLI, credentials, arguments,
environment, host name or private package names. Only release builds
report: the release workflow writes the DSN from the
CHECKLY_CLI_ERROR_DSN repository variable. CHECKLY_NO_ERROR_REPORTING=1
or DO_NOT_TRACK=1 turns it off.
@aluedeke
aluedeke requested a review from sorccu November 3, 2026 10:39
…report

error-reporting.ts did four jobs in one file. It is now a folder:
classify.ts decides what is a CLI bug, scrub.ts removes what names the
user, event.ts builds the Sentry event and report.ts holds the run's
state and sends.

isCliBug now reads the stack with Sentry's Node parser, the same one
the event uses, instead of its own regex, so classification and the
reported in_app frames cannot disagree. Frames of code run through eval
or new Function are traced to the file that ran it. Because the parser
is loaded only when needed, noteCliError keeps a few candidate errors
and the check runs when the command ends.

@sorccu sorccu left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this is careful work. I've marked which findings were reproduced; the rest come from reading the code.

On the open question: let's drop @sentry/core. #1591 is stacked on this branch and replaces it with a local module of about 120 lines: stack parser, DSN → envelope URL, envelope serialization and crypto.randomUUID(). For the same input it produces the same frames and envelope as @sentry/core 11, apart from a few small differences the PR lists. Merge it into this branch to pick it up.

The size matters beyond the CLI install. A project's package.json usually lists checkly, so the bundled package.json of a Playwright Check Suite does too (bundle.packages.prune is opt-in). Our runners would install and cache @sentry/* (~14 MB) for those bundles, though the CLI never runs there.

Major

  1. Errors thrown by built-in methods are never reported (classify.ts, throwingFile). When a built-in throws, V8's innermost frame is <anonymous>, e.g. at String.repeat (<anonymous>). throwingFile returns that, isOwnPath('<anonymous>', …) is false, and the error is classified as not the CLI's. This was reproduced with @sentry/core 11 on Node 24 for 'x'.repeat(-1), [].reduce(fn), new Date('nope').toISOString() and [1].map(undefined). JSON.stringify on a circular object behaves the same way. Suggested fix: skip frames whose filename is not an absolute path or file:// URL (<anonymous>, native, index N) as well as node: frames, so the first real frame decides. Add specs for errors thrown by built-ins.

  2. Errors while processing test results are not reported (abstract-check-runner.ts, configureResultListener). this.queue.add(() => this.processMessage(...)) is never awaited or caught, and the reporters' onCheckStart/onCheckEnd run inside processMessage. A TypeError there becomes an unhandled rejection, and Node ends the process before runner.run()'s catch or BaseCommand.finally runs. Nothing is noted or sent. A synchronous throw in the 'message' handler becomes an uncaught exception, with the same result. This is probably where most real bugs in test/trigger/pw-test would occur: reporters and result formatting. The description lists unhandled rejections as a follow-up, but I'd handle this case in this PR. Catch the queued promise's rejection, call noteCliError, and fail the run so the error goes through run()'s catch and the awaited finally.

  3. Nothing tells users about the new outbound request. Only the README covers it. There is nothing in the docs site and no release note. Users with egress allowlists or data-handling reviews look there, not at the GitHub README. Please add a short docs page covering error reporting and the two opt-out variables, and mention it in the release notes of the version that ships it.

Do reports get sent before the process exits?

On the paths the PR hooks into, yes. oclif's Command._run awaits this.catch() and then this.finally() before the error reaches handle(), which prints and calls process.exit. BaseCommand.finally awaits reportNotedCliError, so the POST completes, or times out after 3 s, before the exit. This holds for errors that are thrown, this.exit()/this.error(), --json mode, and commands that only set process.exitCode. Nothing in src calls process.exit directly, apart from the hand-off to the project's own CLI. The paths that do lose the report are item 2 above and crashes outside a command's run: oclif config loading, hooks, and bin/launch.cjs.

Minor

  • Scrubbing:
    • replaceDir's lookahead does not accept ., !, ] or }. A bare cwd before sentence punctuation (no config in /work/acme-proj.) is left in place, and the home pass misses it when the project is outside home (e.g. cwd=/app/<name>, HOME=/root).
    • The URL-credential regex keeps part of a password that contains @ (http://bob:p@ss@proxy → http://<redacted>@ss@proxy), and key=value redaction stops at a space inside quotes (password: "hunter two" → "<redacted> two"). Rather than patching the regexes, rebuild credential-bearing URLs from scheme and host, and redact quoted values up to the closing quote.
    • Absolute paths outside cwd and home are sent as they are, e.g. a CI checkout under /builds/<org>/<repo> run from a subdirectory with HOME=/root, or --config pointing elsewhere. Consider replacing every absolute path outside the package root.
  • No test covers the production wiring. No test covers BaseCommand.catch/finally, longError or the runner noting errors that then get sent. Dropping one of the noteCliError calls, or no longer awaiting the report, would keep every spec green. The DSN can only come from PACKAGE_ROOT/error-reporting.json, so such a test can't be written yet. A small test-only override would allow one.
  • Nothing checks that the packed tarball contains error-reporting.json. A packaging mistake would silently turn reporting off. One tar -tzf check in the release job would catch it.
  • reportCliError is exported and documented as an entry point, but only the spec calls it.
  • Stale paths: the comments in release.yml and .gitignore still name src/services/error-reporting.ts, which is now a folder.
  • test-sessions/list.ts now calls noteCliError for parseUnixTimestamp failures. If that ever throws a TypeError/RangeError, a user's bad --from value would be reported as a CLI bug.
  • The wait before the error is printed is up to 3 s. That only happens on a real bug, but 1–1.5 s would be plenty for a single POST.
  • Two conventions for printed errors: longError(title, err) notes automatically, but longError(title, err.message) and this.error(err.message) need a manual noteCliError(err). Passing the error object everywhere would mean nobody has to remember the manual call.

🤖 Generated with Claude Code

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants